Skip to content

Strip spoofable forwarded headers at the Fastly edge entry point#505

Open
ChristianPavilonis wants to merge 1 commit intomainfrom
fix/forwarded-header-spoofing
Open

Strip spoofable forwarded headers at the Fastly edge entry point#505
ChristianPavilonis wants to merge 1 commit intomainfrom
fix/forwarded-header-spoofing

Conversation

@ChristianPavilonis
Copy link
Collaborator

Summary

Fixes #409X-Forwarded-Host / Forwarded header spoofing enables URL rewrite hijack.

  • Strip Forwarded, X-Forwarded-Host, X-Forwarded-Proto, and Fastly-SSL headers at the top of route_request() before any routing or header inspection occurs
  • Add log::debug! tracing when a spoofable header is actually stripped (aids incident response)
  • Update RequestInfo and publisher.rs doc comments to reflect the new trust boundary

Why it's safe to remove these headers

On Fastly Compute, the service is the edge — there is no legitimate upstream proxy setting forwarded headers. Any X-Forwarded-Host or Forwarded values present in the request were sent directly by the client and are therefore untrusted.

After stripping, the existing fallback logic in RequestInfo::from_request naturally lands on trustworthy signals:

  • Host: extract_request_host falls through Forwarded (gone) → X-Forwarded-Host (gone) → Host header — which Fastly validates against the service configuration
  • Scheme: detect_request_scheme hits Fastly SDK TLS detection first (priority 1, unchanged by this PR, not a header — it's SDK-level and unspoofable) → forwarded headers (gone) → default http

The forwarded header parsing code in common remains intact for potential non-edge deployment contexts, but the Fastly entry point now enforces the trust boundary.

Impact on integrations

Area Impact
Prebid site.domain / site.page None — sourced from settings.publisher.domain, not RequestInfo
Prebid ad-proxy URLs More correct — Host header is the actual browser-facing domain
Prebid request signing Self-consistent — signing and ext.trusted_server both use the same source
APS None — does not use RequestInfo at all
Permutive SDK URL Same as ad-proxy — Host is the correct domain

Git blame context

The forwarded header logic was introduced in commit 563c281 ("Improve forwarded header parsing for host and scheme") with no security review (PR #176 had an empty description and no review comments). It was speculative support for a hypothetical chained-proxy scenario (Client → Proxy A → Trusted Server) that does not apply to the Fastly Compute deployment model.

Test plan

  • 2 new tests: sanitize_removes_all_spoofable_headers, sanitize_then_request_info_falls_back_to_host
  • All 475 existing tests pass (including the existing RequestInfo forwarded-header parsing tests, which validate the common crate logic independently)
  • cargo fmt, cargo clippy clean

Prevent X-Forwarded-Host / Forwarded header spoofing that allows
attackers to hijack URL rewriting across HTML streaming, ad-proxy URLs,
Prebid OpenRTB requests, and request signing payloads.

Closes #409
Copy link
Collaborator

@aram356 aram356 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Clean, well-scoped security fix. Sanitization is placed at exactly the right point — top of route_request, before any header inspection or routing. No blockers.

Non-blocking

🏕 camp site

  • Dead debug log fields (crates/common/src/publisher.rs:229-236): After edge sanitization, x-forwarded-host and x-forwarded-proto are always None in this debug log. These format fields now add noise without information. Suggest simplifying to:
log::debug!(
    "Request info: host={}, scheme={} (Host: {:?})",
    request_host,
    request_scheme,
    req.get_header(header::HOST),
);

🌱 seedling

  • Defense-in-depth for RequestInfo::from_request: from_request still checks forwarded headers first in its fallback chain. The docs correctly note sanitization happens at the edge, but if a second entry point is added or from_request is called without sanitizing, the old surface is exposed. A future option: accept a newtype wrapper (&SanitizedRequest) instead of &Request to make skipping sanitization a compile error. Not needed now — just a thought for hardening later.

CI Status

  • cargo fmt: PASS
  • cargo test: PASS
  • vitest: PASS
  • format-typescript: PASS
  • format-docs: PASS
  • CodeQL: PASS

// publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled.

// Extract request host and scheme from headers (supports X-Forwarded-Host/Proto for chained proxies)
// Extract request host and scheme (uses Host header and TLS detection after edge sanitization)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏕 camp site — The debug log below (lines 229–236) still formats x-forwarded-host and x-forwarded-proto, but after edge sanitization these are always None. Consider removing those two fields to reduce noise:

log::debug!(
    "Request info: host={}, scheme={} (Host: {:?})",
    request_host,
    request_scheme,
    req.get_header(header::HOST),
);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

X-Forwarded-Host / Forwarded header spoofing enables URL rewrite hijack

2 participants